iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
Software Development

Android 鐵人賽: 天命最高 - 陪伴大家一步步打造屬於自己的app系列 第 10

Android 畫面設計入門——ConstraintLayout、View Binding 與 Fragment 切換

  • 分享至 

  • xImage
  •  

Day 10:Android 畫面設計入門——ConstraintLayout、View Binding 與 Fragment 切換

大家好,我是 Alex。

前幾天已經完成 Kotlin 基本語法、集合、物件導向與協程等內容。從今天開始,我們要正式進入 Android App 的畫面開發。

今天將從 Android Studio 的版面配置編輯器開始,學習如何使用 ConstraintLayout 安排元件位置,再透過 View Binding 操作畫面上的 TextViewButton,最後使用 Fragment 完成多畫面切換及返回功能。

參考資料:Android Developers-開發 UIAndroid Developers-ConstraintLayout


一、Android App 的畫面由什麼組成?

傳統 Android View 系統通常把畫面與程式邏輯分開:

  • XML:負責畫面的外觀、位置與元件配置。
  • Kotlin:負責按鈕事件、資料處理與畫面切換。
  • Activity:代表 App 中的一個主要畫面。
  • Fragment:可放進 Activity 裡重複使用的子畫面。

例如,在 XML 中加入一個 Button 後,可以在 Kotlin 裡設定點擊監聽器;當使用者按下按鈕時,再修改 TextView 的文字或切換至另一個 Fragment。

參考資料:Android Developers-LayoutsAndroid Developers-Activity


二、使用 Android Studio Layout Editor 設計畫面

Android Studio 提供視覺化版面配置編輯器,可以把 TextViewButtonImageView 等元件直接拖曳到畫面中。

版面編輯器主要分成三種檢視模式:

  • Design:顯示畫面實際呈現的樣子。
  • Blueprint:顯示元件之間的約束關係。
  • Code:直接編輯 XML 原始碼。

ConstraintLayout 中,元件不應只靠拖曳決定位置,而是必須建立 Constraint 約束。沒有完整約束的元件,在不同尺寸的手機上可能會出現位移。

參考資料:Android Developers-Layout EditorAndroid Developers-ConstraintLayout


三、認識 ConstraintLayout

ConstraintLayout 是 Android 常用的版面配置之一。每一個元件都可以和父容器或其他元件建立上下左右的約束。

常用的約束屬性如下:

app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"

如果要讓一個 TextView 水平置中,可以把它的左右兩側都約束到父容器:

<TextView
    android:id="@+id/textView_text1"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="Hi good morning"
    android:textSize="24sp"
    android:textStyle="bold"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    android:layout_marginTop="72dp" />

這樣不論手機螢幕寬度如何改變,TextView 都會維持在水平方向的中央。

參考資料:Android Developers-ConstraintLayout 約束


四、使用 Guideline 輔助畫面配置

GuidelineConstraintLayout 中看不見的輔助線。它不會顯示在 App 畫面上,只負責提供其他元件建立約束。

例如,建立一條距離畫面頂端 40dp 的水平 Guideline:

<androidx.constraintlayout.widget.Guideline
    android:id="@+id/guideline"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:orientation="horizontal"
    app:layout_constraintGuide_begin="40dp" />

接著,其他元件就可以約束在 Guideline 下方:

app:layout_constraintTop_toBottomOf="@id/guideline"

除了固定距離,Guideline 也能使用百分比定位:

app:layout_constraintGuide_percent="0.5"

0.5 代表位於父容器的 50% 位置,也就是畫面的正中央。百分比定位通常比寫死固定寬度更能適應不同尺寸的裝置。

參考資料:Android Developers-Guideline


五、使用 findViewById 取得畫面元件

早期常見的 Android View 寫法,是先呼叫 setContentView() 載入 XML,再使用 findViewById() 取得元件。

以下是本次練習中的完整程式:

package com.example.layout2

import android.os.Bundle
import android.view.View
import android.widget.Button
import android.widget.TextView
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat

class MainActivity : AppCompatActivity() {

    // 記錄兩個按鈕目前的切換狀態
    private var buttonFlag1: Boolean = true
    private var buttonFlag2: Boolean = true

    // 使用 lateinit 延後初始化畫面元件
    private lateinit var buttonText2: Button
    private lateinit var buttonText1: Button
    private lateinit var textView3_Content: TextView
    private lateinit var textView2_Center: TextView
    private lateinit var textView1_Title: TextView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 啟用 Edge-to-Edge,讓內容可以延伸到系統列區域
        enableEdgeToEdge()

        // 載入 activity_main.xml
        setContentView(R.layout.activity_main)

        // 讀取系統狀態列與導覽列的範圍,避免內容被遮住
        ViewCompat.setOnApplyWindowInsetsListener(
            findViewById(R.id.main)
        ) { view, insets ->

            val systemBars =
                insets.getInsets(WindowInsetsCompat.Type.systemBars())

            view.setPadding(
                systemBars.left,
                systemBars.top,
                systemBars.right,
                systemBars.bottom
            )

            insets
        }

        // 使用 ID 取得 XML 中的 TextView
        textView1_Title = findViewById(R.id.textView_text1)
        textView2_Center = findViewById(R.id.textView_text2)
        textView3_Content = findViewById(R.id.textView_text3)

        // 取得兩個 TextView 原本顯示的文字
        val data1 = textView1_Title.text
        val data2 = textView2_Center.text

        // 使用字串模板組合內容
        textView3_Content.text = "$data1\n$data2\n\n"

        // 使用 append() 繼續加入文字
        textView3_Content.append(
            data1.toString() + "\n" + data2.toString()
        )

        // 設定按鈕的初始切換狀態
        buttonFlag1 = true
        buttonFlag2 = true

        // 取得第一個按鈕
        buttonText1 = findViewById(R.id.button)

        // 第一個按鈕使用匿名物件設定監聽器
        buttonText1.setOnClickListener(
            object : View.OnClickListener {

                override fun onClick(view: View?) {

                    if (buttonFlag1) {
                        // 第一次點擊時修改第一個 TextView
                        textView1_Title.text = "Hi good afternoon"
                        buttonFlag1 = false
                    } else {
                        // 再次點擊時恢復原本內容
                        textView1_Title.text = data1
                        buttonFlag1 = true
                    }
                }
            }
        )

        // 取得第二個按鈕
        buttonText2 = findViewById(R.id.button2)

        // 第二個按鈕同樣使用匿名物件設定監聽器
        buttonText2.setOnClickListener(
            object : View.OnClickListener {

                override fun onClick(view: View?) {

                    if (buttonFlag2) {
                        // 第一次點擊時修改第二個 TextView
                        textView2_Center.text = "It is a sunny day"
                        buttonFlag2 = false
                    } else {
                        // 再次點擊時恢復原本內容
                        textView2_Center.text = data2
                        buttonFlag2 = true
                    }
                }
            }
        )
    }
}

這個範例使用兩個 Boolean 變數保存按鈕狀態。每按一次按鈕就反轉狀態,讓文字能在新內容與原始內容之間切換。

參考資料:Android Developers-findViewByIdAndroid Developers-處理點擊事件


六、findViewById 有什麼問題?

findViewById() 雖然容易理解,但也有幾個缺點:

  • 每一個元件都要宣告變數。
  • 每一個元件都要手動呼叫 findViewById()
  • ID 或元件型別寫錯時,可能要等到執行階段才發現。
  • 畫面元件越多,程式碼就越冗長。
  • Kotlin 程式與 XML 元件之間缺少足夠的型別安全。

因此,Android 官方提供了 View Binding,它會根據 XML 版面配置自動產生 Binding 類別。

參考資料:Android Developers-View Binding


七、啟用 View Binding

首先開啟 App 模組的 build.gradle.kts,在 android 區塊加入:

android {

    // 其他 Android 專案設定

    buildFeatures {
        // 啟用 View Binding
        viewBinding = true
    }
}

如果使用較早版本的 Android Gradle Plugin,也可能會看到以下寫法:

android {

    // 舊版語法
    viewBinding {
        enable = true
    }
}

修改 Gradle 設定後,需要按下 Android Studio 上方的 Sync Now,讓專案重新同步。

參考資料:Android Developers-啟用 View Binding


八、Binding 類別是如何產生的?

啟用 View Binding 後,Android Studio 會依照 XML 檔名產生 Binding 類別。

XML 檔名 自動產生的 Binding 類別
activity_main.xml ActivityMainBinding
fragment_first.xml FragmentFirstBinding
fragment_second.xml FragmentSecondBinding

XML 中的 ID 也會自動轉成駝峰式命名:

XML ID Kotlin Binding 屬性
button_1 binding.button1
text_view_text1 binding.textViewText1
fragment_container binding.fragmentContainer

因此,不需要再使用 findViewById(),就可以直接取得畫面元件。

參考資料:Android Developers-View Binding 使用方式


九、在 Activity 使用 View Binding

以下是這次練習中的 View Binding 完整程式:

package com.example.layout3

import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.example.layout3.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {

    // 宣告 Activity 對應的 View Binding
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 啟用 Edge-to-Edge 顯示
        enableEdgeToEdge()

        // 根據 activity_main.xml 建立 Binding 物件
        binding = ActivityMainBinding.inflate(layoutInflater)

        // binding.root 代表 XML 最外層的根 View
        setContentView(binding.root)

        // 使用 Binding 直接取得 ID 為 main 的根版面
        ViewCompat.setOnApplyWindowInsetsListener(
            binding.main
        ) { view, insets ->

            val systemBars =
                insets.getInsets(WindowInsetsCompat.Type.systemBars())

            view.setPadding(
                systemBars.left,
                systemBars.top,
                systemBars.right,
                systemBars.bottom
            )

            insets
        }

        // 第一個按鈕被點擊後,修改第一個 TextView
        binding.button1.setOnClickListener {
            binding.textViewText1.text = "Button-1 Clicked"
        }

        // 第二個按鈕被點擊後,修改第二個 TextView
        binding.button2.setOnClickListener {
            binding.textViewText2.text = "Button-2 Clicked"
        }

        // 第三個按鈕被點擊後,修改第三個 TextView
        binding.button3.setOnClickListener {
            binding.textViewText3.text = "Button-3 Clicked"
        }
    }
}

findViewById() 相比,View Binding 的寫法更簡潔。只要輸入 binding.,Android Studio 就會列出目前 XML 中可使用的元件,也能在編譯時檢查元件型別。

參考資料:Android Developers-在 Activity 使用 View Binding


十、Activity 與 Fragment 的關係

Activity 可以想像成一個容器,Fragment 則是放在容器裡面的子畫面。

例如,主畫面中有兩個按鈕:

  • 按下第一個按鈕,顯示 FirstFragment
  • 按下第二個按鈕,顯示 SecondFragment
  • Fragment 中的返回按鈕,可以回到上一個畫面。

這種方式不必為每一個小畫面都建立新的 Activity,也方便在同一個 Activity 中替換不同內容。

參考資料:Android Developers-FragmentsAndroid Developers-FragmentManager


十一、在 XML 放置 Fragment 容器

Activity 的 XML 需要準備一個容器,讓 Fragment 顯示在指定區域。

<androidx.fragment.app.FragmentContainerView
    android:id="@+id/fragmentContainer"
    android:layout_width="0dp"
    android:layout_height="0dp"
    app:layout_constraintTop_toBottomOf="@id/button2"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintBottom_toBottomOf="parent" />

這裡將 layout_widthlayout_height 設為 0dp,代表元件大小由 Constraint 約束決定。在 ConstraintLayout 中,這種設定稱為 match constraints

參考資料:Android Developers-FragmentContainerViewAndroid Developers-ConstraintLayout 尺寸


十二、從 MainActivity 切換 Fragment

以下是加入 Fragment 切換後的完整 MainActivity.kt

package com.example.layout3

import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import com.example.layout3.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {

    // Activity 對應的 View Binding
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 啟用 Edge-to-Edge 顯示
        enableEdgeToEdge()

        // 建立 View Binding
        binding = ActivityMainBinding.inflate(layoutInflater)

        // 將 Binding 的根 View 設為 Activity 畫面
        setContentView(binding.root)

        // 處理狀態列與導覽列範圍
        ViewCompat.setOnApplyWindowInsetsListener(
            binding.main
        ) { view, insets ->

            val systemBars =
                insets.getInsets(WindowInsetsCompat.Type.systemBars())

            view.setPadding(
                systemBars.left,
                systemBars.top,
                systemBars.right,
                systemBars.bottom
            )

            insets
        }

        // 按下第一個按鈕,開啟 FirstFragment
        binding.button1.setOnClickListener {
            replaceFragment(
                fragment = FirstFragment(),
                tag = "first"
            )
        }

        // 按下第二個按鈕,開啟 SecondFragment
        binding.button2.setOnClickListener {
            replaceFragment(
                fragment = SecondFragment(),
                tag = "second"
            )
        }
    }

    /**
     * 將指定的 Fragment 放進 fragmentContainer。
     *
     * @param fragment 準備顯示的 Fragment
     * @param tag 放入返回堆疊時使用的識別名稱
     */
    private fun replaceFragment(
        fragment: Fragment,
        tag: String
    ) {
        supportFragmentManager
            .beginTransaction()

            // 使用新的 Fragment 取代容器中的舊內容
            .replace(
                R.id.fragmentContainer,
                fragment
            )

            // 加入返回堆疊,讓使用者可以回到上一個畫面
            .addToBackStack(tag)

            // 提交這次 FragmentTransaction
            .commit()
    }

    /**
     * 處理 Toolbar 向上返回按鈕。
     */
    override fun onSupportNavigateUp(): Boolean {

        return if (
            supportFragmentManager.backStackEntryCount > 0
        ) {
            // 返回堆疊中還有畫面時,移除最上層 Fragment
            supportFragmentManager.popBackStack()
            true
        } else {
            // 沒有 Fragment 可返回時,交由 Activity 原本的行為處理
            super.onSupportNavigateUp()
        }
    }
}

replace() 負責把新的 Fragment 放進容器,而 addToBackStack() 則會記錄這次切換。若沒有呼叫 addToBackStack(),使用者按下返回鍵時就不能回到前一個 Fragment 狀態。

參考資料:Android Developers-FragmentTransactionAndroid Developers-Fragment 返回堆疊


十三、建立 FirstFragment

FirstFragment 透過 onCreateView() 載入 fragment_first.xml。按下 Fragment 裡的返回按鈕後,呼叫 popBackStack() 回到上一層。

完整程式如下:

package com.example.layout3

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import androidx.fragment.app.Fragment

class FirstFragment : Fragment() {

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {

        // 載入 fragment_first.xml
        // container 是這個 Fragment 將被放入的父容器
        val view = inflater.inflate(
            R.layout.fragment_first,
            container,
            false
        )

        // 取得 Fragment 畫面裡的返回按鈕
        val backButton =
            view.findViewById<Button>(R.id.buttonBack)

        // 按下按鈕後,移除返回堆疊最上層的 Fragment
        backButton.setOnClickListener {
            parentFragmentManager.popBackStack()
        }

        // 回傳建立完成的 Fragment View
        return view
    }
}

parentFragmentManager 是管理此 Fragment 的 FragmentManager。呼叫 popBackStack() 後,會移除目前位於返回堆疊最上層的 Fragment 交易。

參考資料:Android Developers-建立 FragmentAndroid Developers-FragmentManager.popBackStack


十四、建立 SecondFragment

第二個 Fragment 的結構與第一個相同,只是載入不同的 XML:

package com.example.layout3

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import androidx.fragment.app.Fragment

class SecondFragment : Fragment() {

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {

        // 載入 fragment_second.xml
        val view = inflater.inflate(
            R.layout.fragment_second,
            container,
            false
        )

        // 取得第二個 Fragment 畫面裡的返回按鈕
        val backButton =
            view.findViewById<Button>(R.id.buttonBack)

        // 返回前一個畫面
        backButton.setOnClickListener {
            parentFragmentManager.popBackStack()
        }

        // 回傳 Fragment 的根 View
        return view
    }
}

這兩個 Fragment 都使用相同 ID 的 buttonBack。由於它們分別屬於不同的 XML 版面配置,因此不會互相衝突。

參考資料:Android Developers-FragmentAndroid Developers-Fragment 返回堆疊


十五、完整操作流程

這個範例執行後的流程如下:

  1. MainActivity 使用 View Binding 載入 activity_main.xml
  2. 使用者按下 button1
  3. 程式建立 FirstFragment
  4. replaceFragment() 把 Fragment 放入 fragmentContainer
  5. addToBackStack("first") 記錄這次畫面交易。
  6. 使用者在 Fragment 中按下返回按鈕。
  7. popBackStack() 移除目前 Fragment。
  8. 畫面回到原本的 Activity。

第二個按鈕與 SecondFragment 也會執行相同流程。

參考資料:Android Developers-Fragment 交易


十六、View Binding 與 findViewById 比較

比較項目 findViewById View Binding
取得元件方式 手動輸入 ID Binding 自動產生屬性
型別安全 較低 較高
空值安全 需要自行處理 對現有 View 提供直接參照
程式碼長度 較多 較精簡
編譯時檢查 有限 可檢查多數 ID 與型別問題
適合情境 舊專案、基本教學 新專案與一般 Android 開發

對剛開始學 Android 的人來說,先理解 findViewById() 有助於認識 XML 與 Kotlin 的連結方式;實際建立新專案時,則建議使用 View Binding。

參考資料:Android Developers-View Binding


十七、今天學到的重點

今天正式跨入 Android App 的畫面開發,完成的內容包括:

  • 使用 Android Studio Layout Editor 設計畫面。
  • 使用 ConstraintLayout 建立元件約束。
  • 使用 Guideline 輔助元件定位。
  • 使用 findViewById() 取得 XML 元件。
  • 使用按鈕事件修改 TextView 文字。
  • 使用 Boolean 變數保存按鈕切換狀態。
  • 在 Gradle 中啟用 View Binding。
  • 使用 ActivityMainBinding 操作畫面元件。
  • 使用 FragmentContainerView 準備 Fragment 容器。
  • 使用 FragmentTransaction.replace() 切換畫面。
  • 使用 addToBackStack() 保存畫面切換紀錄。
  • 使用 popBackStack() 返回上一個畫面。

從這一天開始,我們已經不只是撰寫顯示在終端機裡的 Kotlin 程式,而是真正讓程式與 Android 手機畫面互動。下一步可以繼續整理 Activity 與 Fragment 的生命週期,以及 Fragment 中更安全的 View Binding 寫法。

參考資料:Android Developers-View 系統Android Developers-Fragment 生命週期


上一篇
Kotlin 協程進階與檔案處理——CoroutineScope、SupervisorJob、async 與 File I/O
下一篇
Android Widget 實戰——計數器、圖片切換與共用按鈕事件
系列文
Android 鐵人賽: 天命最高 - 陪伴大家一步步打造屬於自己的app13
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言